Skip to content

Audit part 2: episode types, proxy credential relay, player state leaks - #25

Merged
Shik3i merged 2 commits into
mainfrom
fix/audit-parser-proxy-player
Aug 15, 2026
Merged

Audit part 2: episode types, proxy credential relay, player state leaks#25
Shik3i merged 2 commits into
mainfrom
fix/audit-parser-proxy-player

Conversation

@Shik3i

@Shik3i Shik3i commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Continuation of the audit into the three areas the first pass never opened: the Android player module (2.8k lines), rss/parser.go, and the remainder of proxy.go.

itunes:episodeType was parsed and thrown away

parseRSS computed the value, defaulted it to "full" — and then left the field out of the struct literal. So every episode ever ingested stored an empty episode_type.

The Android Inbox's "hide specials" filter keys on it:

episodeType.lowercase() in setOf("trailer", "bonus") ||
    listOf("trailer", "teaser", "preview", "bonus", "vorschau").any(text::contains)

With the left half permanently false, the feature degraded to guessing from the title — a correctly tagged <itunes:episodeType>trailer</itunes:episodeType> with a neutral title was never filtered. Atom carries no equivalent tag, so an entry is "full" by the same rule the RSS branch already applied to untagged items.

Existing rows heal on the next feed refresh; the worker's UPDATE already writes episode_type.

The proxies relayed credentials embedded in a URL

Both proxy endpoints take a URL straight out of a publisher's feed — their own comment calls it "fully attacker-controlled". The SSRF-safe dialer resolves and vets every address, on the initial request and again on each redirect, but it never looks at the userinfo component. So https://user:secret@host/ in an artwork or enclosure tag was presented upstream by the instance itself.

Only that gap is closed. My first attempt applied the full rss.ValidateURL and broke four existing tests: it duplicates the dialer's address policy, which also rejects the loopback targets the test transport is deliberately allowed to reach. The check is now narrowed to what the dialer genuinely cannot see.

A sleep timer outlived the episode it was set on

"Stop at the end of this chapter" is a media position, and only the natural end of an episode cleared it. Skipping to the next track carried the target across and paused the new episode at a position that meant nothing there. Both the service and the UI state reset it on an episode change now.

Same transition: the per-podcast skip-outro value was cached until the podcast changed, so a change made while that show was playing never took effect.

Verification

  • Go: go vet + full suite green, with regression tests for both parser branches, the credential rejection, and the loopback target that must keep working.
  • Android: testDebugUnitTest + testReleaseUnitTest green, :app:assembleDebug builds.
  • On the emulator: playback starts (MediaSession state=PLAYING), and after pausing, the position (34,378 ms) and the listening session (34,494 ms wall clock, matching audio time at 1.0×) are both persisted. No crashes.

Coverage, honestly

This pass covered roughly 4,000 more lines. Still unaudited: most web route components, prefs.svelte.ts, idb/db.ts in full, and server-side opml.go, admin.go, global_stats.go, account.go, itunes/client.go, db/sqlite.go.

No version bump — nothing here is urgent enough to re-cut a release on its own.

🤖 Generated with Claude Code

Shik3i and others added 2 commits August 15, 2026 17:03
Second pass of the audit, over the areas the first pass never opened: the
Android player module, the RSS parser, and the rest of the image/audio proxy.

itunes:episodeType was parsed and thrown away

parseRSS computed the value, defaulted it to "full", and then left the field
out of the struct literal — so every episode ever ingested stored an empty
episode_type. The Inbox's "hide specials" filter keys on trailer/bonus and had
nothing to key on, degrading to guessing from the title: a properly tagged
trailer with a neutral title was never filtered. Atom carries no equivalent
tag, so an entry is "full" by the same rule the RSS branch already applied to
untagged items. Existing rows heal on the next feed refresh, which already
writes episode_type on update.

The proxies relayed credentials embedded in a URL

Both proxy endpoints take a URL straight out of a publisher's feed. The
SSRF-safe dialer resolves and vets every address, on the initial request and
on each redirect, but it never looks at the userinfo component — so
`https://user:secret@host/` in an artwork or enclosure tag was presented
upstream by the instance itself. Only that gap is closed here: repeating the
dialer's address policy in the handler would also reject the loopback targets
the transport is deliberately allowed to reach.

A sleep timer outlived the episode it was set on

"Stop at the end of this chapter" is a media position, and only the natural
end of an episode cleared it. Skipping to the next track carried the target
across and paused the new episode at a position that meant nothing there. The
service and the UI state both reset it on an episode change now. The
per-podcast outro setting was cached until the podcast changed, so a change
made while that show was playing never took effect; it is re-read on the same
transition.

Verified on an emulator: playback runs, position (34.4 s) and the listening
session (34.5 s wall clock, matching audio time at 1.0x) both persist, no
crashes. Go and both Android unit-test tasks pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third pass of the audit, over the remaining server handlers and the Android
data layer.

An instance could be left with no administrator. SuspendUser counted the
active admins outside its write transaction, so two administrators suspending
each other at the same time both saw two, both passed the guard, and the
result was an instance nobody could administer — recoverable only by editing
the database by hand. The count moves inside the transaction, which is
BEGIN IMMEDIATE, so the second request waits and sees the first one's effect.

Two list endpoints ignored rows.Err(). A read that stops early leaves a short
slice that looks like a complete answer:

- ListSessions is what a listener uses to spot a session they do not
  recognise, and the one row missing from a truncated read could be exactly
  that one.
- ExportAccount is offered as the listener's complete copy of their own data.
  A file that is quietly missing rows is worse than a failed request.

Both now fail the request instead.

Also corrects the AutoDownloadWorker scheduling comment, which claimed the
job runs on an unmetered network while the constraint says CONNECTED. The
behaviour is right — that pass only reads feed metadata, and "download over
Wi-Fi only" becomes an UNMETERED constraint in DownloadRepository.enqueue,
on the transfer that actually spends the listener's data — but the comment
described a guarantee the code does not make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Shik3i

Shik3i commented Aug 15, 2026

Copy link
Copy Markdown
Owner Author

Third pass added to this branch

Continued through the remaining server handlers (account.go, admin.go, opml.go, global_stats.go) and the Android data layer.

An instance could be left with no administrator. SuspendUser counted active admins outside its write transaction. Two administrators suspending each other concurrently both saw two, both passed the guard, and the instance was left unadministrable — recoverable only by editing the database by hand. The count is inside the transaction now; every transaction here is BEGIN IMMEDIATE, so the second request waits and sees the first one's effect.

Two list endpoints ignored rows.Err(), so a read that stopped early returned a short slice that looks like a complete answer:

  • ListSessions is what a listener uses to spot a session they don't recognise — the row missing from a truncated read could be exactly that one.
  • ExportAccount is offered as the complete copy of their own data.

Both fail the request now.

Corrected a misleading comment in AutoDownloadWorker: it claimed the job runs on an unmetered network while the constraint is CONNECTED. The behaviour is correct — that pass only reads feed metadata, and "download over Wi-Fi only" becomes an UNMETERED constraint in DownloadRepository.enqueue on the transfer that actually spends data — but the comment described a guarantee the code doesn't make.

Checked and found sound

DeleteSynchronizedData (verified all 11 table names exist — a typo there would have made the whole feature 500 permanently), OPML import (bounded fan-out, per-URL SSRF validation, Go's XML decoder rejects entity expansion), admin authorization, the settings-merge field lists (web and Android agree; date_format/ui_language are web-only exactly as the spec documents).

One product question, not a bug

global_stats_opt_in_at is recorded on opt-in and returned to the client, but the aggregation never filters on it — so opting in contributes your entire listening history retroactively, not just from that moment. The privacy policy says participation is opt-in and revocable, and doesn't promise forward-only, so I've left the semantics alone. Worth a decision.

Still unaudited

The web route components (~7,800 lines across settings/library/search/account/inbox), idb/db.ts in full, itunes/client.go, podcastindex/client.go, db/sqlite.go.

@Shik3i
Shik3i merged commit eb9c36a into main Aug 15, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant